Skip to content

S01-07 Servlet-会话-Session ​

[TOC]

概述 ​

什么是 Session ​

Session(会话) 是 JavaWeb 中用于在服务端维护客户端与服务器之间连续会话状态的核心机制,代表客户端与服务端之间一次连续、完整的交互周期。从用户首次打开站点、连续浏览页面,到最终关闭浏览器或长时间无操作超时,整个过程构成一个逻辑上的会话。

存储机制定位 ​

与将数据明文或密文存放在浏览器端的 Cookie 不同,Session 将会话数据完整保存在服务端的内存或外部存储介质中。客户端仅需在 Cookie 中保存一个随机生成的会话标识(JSESSIONID),服务端即可据此检索出对应的上下文信息。

比较维度CookieSession
存储位置客户端(浏览器内存或磁盘文件)服务端(容器内存、文件或集中缓存)
安全性较低,数据存放在客户端易被拦截或篡改较高,敏感业务数据不直接暴露给前端
存储容量单个 Cookie 大小一般限制在 4KB 左右受服务端内存及存储介质容量限制
数据格式仅支持 ASCII 字符串支持任意 Java 对象(引用类型)

基础操作 ​

在 JavaWeb 开发中,对 Session 的操作均通过 jakarta.servlet.http.HttpSession(早期版本为 javax.servlet.http.HttpSession)接口完成。核心操作主要涵盖 获取会话、数据存取、状态检查 和 生命周期控制 四大分类。

HttpSession 封装了在单次会话期间对客户端状态的全部读写与维护接口:

会话获取 ​

通过当前请求对象 HttpServletRequest 可以获取或创建对应的 HttpSession 实例。

默认创建模式:

使用无参的 request.getSession() 等价于 request.getSession(true)。如果当前请求已携带有效的 JSESSIONID 且服务端存在匹配的 Session,则直接返回该对象;如果客户端未携带或会话已失效,服务端会自动创建一个全新的 Session 并分配新的 ID。

探测安全模式:

使用 request.getSession(false) 进行探测式获取。如果当前存在有效会话则返回该对象;如果不存在,则返回 null,不会主动创建新实例。该方法常用于校验用户是否登录或只读操作,避免因频繁创建空白 Session 耗尽服务器内存。

java
// 默认获取:存在则返回,不存在则新建
HttpSession session = request.getSession();

// 探测获取:存在则返回,不存在则返回 null
HttpSession existingSession = request.getSession(false);

属性存取 ​

Session 作为一个服务端的作用域容器,支持将任意 Java 引用类型以键值对的形式存入并跨请求共享。

属性绑定与写入:

调用 setAttribute(String name, Object value) 将对象绑定至当前会话。如果已存在同名键,新传入的值将覆盖旧值。

属性读取与类型转换:

调用 getAttribute(String name) 读取绑定的对象。由于返回值类型为 Object,获取后需要强制转换为实际的目标类型。如果指定的键不存在,则返回 null。

属性移除:

调用 removeAttribute(String name) 显式删除指定键值对,从内存中释放对该对象的引用。

java
// 绑定属性:向会话容器写入用户信息
session.setAttribute("user", userObj);

// 读取属性:取出对象并进行强制类型转换
User currentUser = (User) session.getAttribute("user");

// 移除属性:删除当前会话中的指定键值
session.removeAttribute("user");

状态读取 ​

服务端可以通过 Session 暴露的元信息接口感知当前会话的健康状况与访问轨迹。

会话标识与新旧判断:

  • getId():获取当前会话唯一的 JSESSIONID 字符串。

  • isNew():判断当前会话是否为本次请求中刚由服务器创建的新实例(尚未被客户端确认接收并回传)。

访问时间戳检测:

  • getCreationTime():返回该会话被创建时的毫秒级时间戳。

  • getLastAccessedTime():返回该会话最后一次收到客户端请求的毫秒级时间戳,常用于监测用户活跃度。

    java
    // 获取当前会话唯一的 JSESSIONID 标识符
    String sessionId = session.getId();
    
    // 判断当前会话是否为刚创建的新会话
    boolean isNewSession = session.isNew();
    
    // 获取服务端创建该会话的毫秒级时间戳
    long createTime = session.getCreationTime();
    
    // 获取当前会话最近一次交互的毫秒级时间戳
    long lastAccessTime = session.getLastAccessedTime();

时效控制 ​

Session 占据服务端宝贵的堆内存空间,必须对空闲存活时间进行管控,并在必要时显式销毁。

最大非活跃时间:

调用 setMaxInactiveInterval(int interval) 动态设置单条会话的最大空闲超时时长,单位为秒。当客户端在该时间段内没有任何 HTTP 请求到达,服务端会自动回收销毁该 Session。调用 getMaxInactiveInterval() 可以获取当前的设置值。

手动使会话失效:

调用 invalidate() 方法会立即解除当前 Session 绑定的所有属性,并将其从服务端内部的管理映射表中移除。该方法常用于用户注销(Logout) 场景。

java
// 动态设置当前会话的最大非活跃存活时间(单位:秒)
session.setMaxInactiveInterval(1800);

// 查询当前会话配置的最大空闲超时时长
int maxInterval = session.getMaxInactiveInterval();

// 立即废弃会话并释放服务端所有关联属性
session.invalidate();

示例:Session 交互流程 ​

通过一组联动的 Servlet 演示会话创建、下发凭证与跨请求溯源的完整过程:

  1. 首次握手下发(/session/create):调用 request.getSession() 创建会话并绑定数据,服务端在响应头中自动注入 Set-Cookie 指令。

    java
    @WebServlet("/session/create")
    public class CreateSessionServlet extends HttpServlet {
      @Override
      protected void doGet(HttpServletRequest request, HttpServletResponse response)
          throws IOException {
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
    
        // 1. 获取 Session(首次访问无凭据,容器自动新建)
        HttpSession session = request.getSession();
    
        // 2. 写入业务数据并提取会话标识
        String sessionId = session.getId();
        boolean isNew = session.isNew();
        session.setAttribute("username", "admin");
    
        out.println("<h3>【步骤 1:首次交互握手】</h3>");
        out.println("<p>Session ID: <b>" + sessionId + "</b></p>");
        out.println("<p>是否为新会话 (isNew): <b style='color:green;'>" + isNew + "</b> (预期: true)</p>");
        out.println("<p>已绑定属性:<code>username = admin</code></p>");
        out.println("<p>响应头已植入 <code>Set-Cookie: JSESSIONID=...</code></p>");
        out.println("<p><a href='" + request.getContextPath() + "/session/access'>点击进入步骤 2:验证后续状态溯源</a></p>");
      }
    }
  2. 后续状态溯源(/session/access):客户端携带 Cookie: JSESSIONID=... 再次请求,服务端定位已有会话并读取数据。

    java
    @WebServlet("/session/access")
    public class AccessSessionServlet extends HttpServlet {
      @Override
      protected void doGet(HttpServletRequest request, HttpServletResponse response)
          throws IOException {
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
    
        // 1. 获取 Session(根据请求头携带的 JSESSIONID 检索复用已有会话)
        HttpSession session = request.getSession();
    
        String sessionId = session.getId();
        boolean isNew = session.isNew();
        // 2. 读取上一步存入的属性
        String username = (String) session.getAttribute("username");
    
        out.println("<h3>【步骤 2:后续状态溯源】</h3>");
        out.println("<p>Session ID: <b>" + sessionId + "</b></p>");
        out.println("<p>是否为新会话 (isNew): <b style='color:blue;'>" + isNew + "</b> (预期: false)</p>");
        out.println("<p>跨请求读取属性:<code>username = " + username + "</code></p>");
        out.println("<p>请求头已自动携带 <code>Cookie: JSESSIONID=...</code></p>");
      }
    }

流转状态对比:

观察维度第 1 次请求:/session/create(首次握手)第 2 次请求:/session/access(后续溯源)
请求头 (Request Headers)无 Cookie: JSESSIONID=...携带 Cookie: JSESSIONID=xxxxxx
服务端内部行为容器在内存中新建 Session 并生成随机 ID依据 JSESSIONID 从会话映射表中检索复用
session.isNew()true(本次请求新创建)false(成功命中已有会话)
session.getId()分配全新唯一 ID(如 A1B2C3...)保持完全一致(A1B2C3...)
响应头 (Response Headers)注入指令:Set-Cookie: JSESSIONID=...; Path=/; HttpOnly无 Set-Cookie 指令(避免重复下发)
数据存取结果写入属性 setAttribute("username", "admin")成功读取 getAttribute("username") -> "admin"

实战:用户登录鉴权 ​

在实际工程中,上述基础操作通常协同完成用户认证与鉴权流程。

  1. 用户提交凭证通过认证后,后端调用 request.getSession() 创建会话,通过 setAttribute 存储用户身份。

  2. 权限拦截器拦截受保护请求,使用 request.getSession(false) 探测会话是否存在;若为 null 或属性丢失则重定向到登录界面。

  3. 用户主动登出时,后端调用 session.invalidate() 销毁会话,防止残留会话被他人盗用。

image-20260920233543454

架构协同设计

该案例在 Cookie 章节:自动填写登录账户 的基础之上扩展演进。沿用原案例中 LoginServlet(/login)提供的用户名自动回显登录界面,通过新增受保护后台资源与注销接口,构建完整的认证、鉴权与登出闭环:

  • LoginCheckServlet(认证升级):映射路径 /loginCheck。在校验用户名密码(hspedu / 123456)成功后,除保留原有的 Cookie 用户名回显设置外,调用 request.getSession() 创建服务端会话并存入登录身份标识 loginUser,随后重定向至受保护页面 /manage。
  • ManageServlet(受保护资源鉴权):映射路径 /manage。模拟需要登录才可访问的管理中心。在响应前调用 request.getSession(false) 探测会话;若未登录(Session 为 null 或缺失身份标识),则强制重定向回 /login;鉴权通过则动态渲染管理界面,展示当前登录用户名与注销入口。
  • LogoutServlet(安全退出与销毁):映射路径 /logout。调用 request.getSession(false) 获取当前会话并执行 session.invalidate(),彻底清除服务端会话数据,最后重定向回登录页。

代码实现

1. 凭据校验与会话绑定(LoginCheckServlet 升级)

校验表单提交的账号密码,验证成功后开辟 Session 保存当前用户凭证,并重定向至受保护资源:

java
package com.hspedu.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(urlPatterns = "/loginCheck")
public class LoginCheckServlet extends HttpServlet {
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    req.setCharacterEncoding("utf-8");
    resp.setContentType("text/html;charset=utf-8");
    PrintWriter out = resp.getWriter();

    String username = req.getParameter("username");
    String password = req.getParameter("password");

    // 1. 业务凭据校验
    if ("hspedu".equals(username) && "123456".equals(password)) {
      // 2. 沿用 Cookie 机制:记住用户名 3 天供登录页自动回填
      Cookie cookie = new Cookie("loginUser", username);
      cookie.setMaxAge(3 * 24 * 3600);
      cookie.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath());
      resp.addCookie(cookie);

      // 3. 核心鉴权:创建/获取当前用户 Session,并在服务端存储身份凭据
      HttpSession session = req.getSession();
      session.setAttribute("loginUser", username);

      // 4. 重定向跳转至受保护的管理后台
      resp.sendRedirect(req.getContextPath() + "/manage");
    } else {
      // 登录失败提示
      out.println("<!DOCTYPE html>");
      out.println("<html><head><meta charset='UTF-8'><title>登录结果</title></head><body>");
      out.println("<h2 style='color: red;'>登录失败!</h2>");
      out.println("<p>用户名或密码错误,请检查后重新输入。</p>");
      out.println("<p><a href='" + req.getContextPath() + "/login'>重新登录</a></p>");
      out.println("</body></html>");
    }
  }

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    doPost(req, resp);
  }
}

2. 受保护资源鉴权与后台展示(ManageServlet)

在受保护资源的入口处执行权限拦截,通过 request.getSession(false) 阻断未经认证的非法访问:

java
package com.hspedu.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(urlPatterns = "/manage")
public class ManageServlet extends HttpServlet {

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    resp.setContentType("text/html;charset=utf-8");
    PrintWriter out = resp.getWriter();

    // 1. 探测模式获取会话:只查不建,避免为未登录请求无端开辟会话内存
    HttpSession session = req.getSession(false);

    // 2. 权限校验:若会话不存在或身份标识丢失,判定为未认证
    if (session == null || session.getAttribute("loginUser") == null) {
      // 未登录直接拦截并重定向到登录页面
      resp.sendRedirect(req.getContextPath() + "/login");
      return;
    }

    // 3. 提取已认证的用户身份信息
    String username = (String) session.getAttribute("loginUser");

    // 4. 动态渲染受保护的管理后台
    out.println("<!DOCTYPE html>");
    out.println("<html>");
    out.println("<head><meta charset='UTF-8'><title>管理后台</title></head>");
    out.println("<body>");
    out.println("  <h1>系统管理后台</h1>");
    out.println("  <p style='color: green;'>欢迎您,<strong>" + username + "</strong>![当前处于登录鉴权保护区]</p>");
    out.println("  <p>Session ID: <code>" + session.getId() + "</code></p>");
    out.println("  <hr/>");
    out.println("  <p>这是仅供登录用户访问的敏感业务数据内容...</p>");
    out.println("  <p><a href='" + req.getContextPath() + "/logout'>【安全退出登录】</a></p>");
    out.println("</body>");
    out.println("</html>");
  }

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    doGet(req, resp);
  }
}

3. 会话销毁与安全登出(LogoutServlet)

调用 session.invalidate() 主动废弃服务端会话,解绑敏感数据并重定向至登录页:

java
package com.hspedu.servlet;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@WebServlet(urlPatterns = "/logout")
public class LogoutServlet extends HttpServlet {

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    // 1. 探测当前客户端是否持有有效会话
    HttpSession session = req.getSession(false);

    // 2. 若存在会话,立即执行注销并清空内部绑定的所有数据
    if (session != null) {
      session.invalidate();
    }

    // 3. 重定向回登录页面
    resp.sendRedirect(req.getContextPath() + "/login");
  }

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws ServletException, IOException {
    doGet(req, resp);
  }
}

流转验证与时序说明

  1. 未登录越权拦截:用户直接在浏览器访问受保护资源 http://localhost:8080/应用名/manage。由于客户端未携带任何会话凭证,req.getSession(false) 探测结果为 null,触发 resp.sendRedirect(...),请求被强制遣返重定向至 /login。
  2. 凭据认证与会话创建:用户在 /login 登录表单输入 hspedu 与 123456。提交至 /loginCheck 验证通过,服务端执行 req.getSession() 开辟新会话,并将身份标识绑定为 session.setAttribute("loginUser", ...),随后下发 Set-Cookie: JSESSIONID=... 并重定向至 /manage。
  3. 受保护资源鉴权放行:浏览器重定向请求自动携带 Cookie: JSESSIONID=... 访问 /manage。ManageServlet 调用 req.getSession(false) 成功定位已有 Session,取出 loginUser,鉴权通过,渲染管理后台。
  4. 安全登出与会话销毁:用户点击“安全退出”,向 /logout 发起请求。Servlet 调用 session.invalidate() 彻底释放并清除该 Session。后续若用户再次访问或刷新 /manage,因原会话已彻底废弃,将再次被拦截重定向回登录页。

核心细节剖析

  • 为什么受保护资源鉴权必须使用 getSession(false)?
    • 若在此处误用 req.getSession()(等价于 getSession(true)),当未登录用户或恶意扫描爬虫访问受保护资源时,服务端每次都会在内存中新建一个空白 Session 并分配 ID。在大规模恶意扫描或高频访问时,服务器内存将被大量无意义的垃圾 Session 迅速占满甚至发生 OOM。采用 false 探测模式只查不建,不存在时直接返回 null,确保内存开销最小化。
  • 为什么登出必须调用 invalidate() 而不能仅清除浏览器 Cookie?
    • Cookie 只是客户端留存的会话指针,真正的认证身份信息与业务数据保存在服务端的 HttpSession 实例中。若仅在前端清空 Cookie 或简单跳转,服务端 Session 在存活期内依然有效。一旦历史 Session ID 被窃取或拦截,攻击者仍可携带该 ID 发起跨站请求伪造或会话固定攻击。只有调用 session.invalidate() 从服务端底层将会话彻底注销解绑,才能保障登出安全。

API: HttpSession ​

会话属性操作 ​

  • abstract Object getAttribute():(String name),获取属性值。检索当前会话中与指定名称绑定的对象,若不存在则返回 null。

  • abstract Enumeration<String>
    getAttributeNames()
    :(),获取所有属性名。返回绑定到此会话的所有属性名称的 Enumeration 集合。

  • abstract void setAttribute():(String name, Object value),绑定/覆盖属性值。以指定名称将对象绑定到当前会话;若已存在同名键则覆盖旧值,传入 null 等效于调用 removeAttribute()。

  • abstract void removeAttribute():(String name),移除属性值。从当前会话中解绑指定名称的对象,若不存在该键则不执行任何操作。

注意事项:

  1. setAttribute() 存入的对象在分布式部署或容器会话钝化场景中必须实现 java.io.Serializable 接口,否则容器序列化时将抛出 java.io.NotSerializableException。
  2. HttpSession 并非线程安全容器。当同一客户端并发发送多个 HTTP 请求时,存在并发读写同名属性的竞争条件,涉及多步骤状态更新时应在业务层加锁或通过原子引用保护。
  3. 已废弃的历史方法:getValue()、putValue()、removeValue() 与 getValueNames() 自 Servlet 2.2 起已被 getAttribute()、setAttribute() 等统一命名方法取代,严禁在新代码中使用。
java
// 1. 将对象以键值对形式持久化绑定到当前会话
session.setAttribute("userAccount", "dev_admin");
session.setAttribute("userLevel", 5);
// 2. 按键检索属性并进行向下转型
String account = (String) session.getAttribute("userAccount");
Enumeration<String> attributeNames = session.getAttributeNames();
// 3. 显式清除特定的会话属性
session.removeAttribute("userLevel");

会话生命周期控制 ​

  • abstract void invalidate():(),注销会话。立即解绑并释放该会话持有的所有属性对象,标记该会话完全失效。

  • abstract void setMaxInactiveInterval():(int interval),设置最大非活动间隔。指定两次客户端请求之间的最长等待秒数。超过该秒数未收到新请求,容器自动销毁该会话;设为负数表示永不过期。

  • abstract int getMaxInactiveInterval():(),获取最大非活动间隔。返回容器在销毁此会话前允许客户端保持非活动的有效秒数。

  • abstract boolean isNew():(),检查新会话状态。若服务端已开辟该会话但客户端尚未加入(尚未在后续请求中携带匹配的 Cookie 确认),则返回 true。

注意事项:

  1. 一旦调用 invalidate(),该会话立即进入不可用状态。后续若在此对象上调用除 getId() 外的任何方法(如 getAttribute()、getCreationTime()),均会抛出 IllegalStateException。
  2. setMaxInactiveInterval(int interval) 接收的参数单位为秒,而 web.xml 配置文件中 <session-timeout> 标签的单位为分钟,开发中需严格区分换算。
java
// 1. 设置最大非活动超时时间为 1800 秒(30 分钟)
session.setMaxInactiveInterval(1800);
int maxInactiveInterval = session.getMaxInactiveInterval();
// 2. 判断客户端是否为首次访问且尚未在客户端存留 Cookie
boolean isNewSession = session.isNew();
if (!isNewSession) {
  // 3. 强制注销会话并销毁会话资源
  session.invalidate();
}

会话元数据查询 ​

  • abstract String getId():(),获取会话标识。返回由容器为当前会话生成的全局唯一标识符字符串。

  • abstract long getCreationTime():(),获取创建时间戳。返回该会话创建的时间(自 1970 年 1 月 1 日 00:00:00 GMT 起计算的毫秒数)。

  • abstract long getLastAccessedTime():(),获取最后访问时间戳。返回客户端最后一次向此会话发送请求的时间戳(毫秒数)。

  • abstract ServletContext
    getServletContext()
    :(),获取应用上下文。返回当前会话所属的全局 ServletContext 对象,用于跨会话共享全局资源。

注意事项:

  1. 会话被 invalidate() 销毁后,getId() 仍可返回历史标识,但调用 getCreationTime() 与 getLastAccessedTime() 均会抛出 IllegalStateException。
  2. 禁止将未脱敏的 getId() 返回值直接暴露在前端页面展示或不可信系统日志中,防止会话 ID 泄露导致会话劫持(Session Hijacking)。
  3. 已废弃的历史方法:getSessionContext() 自 Servlet 2.1 起已废弃且为空实现,调用可能返回空或直接受限。
java
// 1. 获取会话的全局唯一标识字符串
String sessionId = session.getId();
// 2. 获取会话创建与最后访问时间戳(毫秒)并计算活跃间隔
long creationTime = session.getCreationTime();
long lastAccessedTime = session.getLastAccessedTime();
long sessionAgeMillis = lastAccessedTime - creationTime;
// 3. 逆向获取当前 Web 应用的全局上下文对象
ServletContext servletContext = session.getServletContext();

核心原理 ​

Session 的底层核心原理建立在服务端数据存储与客户端凭证传递的协作基础之上,通过在双端维护映射纽带来克服 HTTP 协议的无状态缺陷。

哈希映射机制 ​

Session 在服务端并非独立实体,而是依赖容器内存中的哈希映射结构进行集中化统一调度。

内存映射表:

在主流 Servlet 容器(如 Tomcat)底层,会话管理器维护着一个并发安全的全局集合(类似于 ConcurrentHashMap<String, Session>)。

  • Key:全局唯一的字符串凭证,规范命名为 JSESSIONID。
  • Value:对应的 HttpSession 实例对象,其内部封装了属性字典、最后访问时间戳及存活时限。

凭证生成规则:

当服务端决定创建新会话时,会通过内置的安全伪随机数生成器(SecureRandom) 计算出一串不可预测的高熵字符串。该标识具备全系统唯一性和抗碰撞性,防止攻击者通过暴力穷举伪造合法用户的会话凭证。

交互流程 ​

客户端与服务端通过标准 HTTP 请求头与响应头协同完成会话状态的绑定与溯源。

首次交互握手:

  1. 客户端发起 HTTP 请求,请求头中未包含任何会话标识。

  2. 服务端业务层调用 request.getSession(),容器检索不到凭证,遂在内存中生成新的 Session 实例(KV结构) 及对应的 JSESSIONID。

  3. 服务端构建 HTTP 响应报文,在响应头中植入设置指令:Set-Cookie: JSESSIONID=xxxxxx; Path=/; HttpOnly。

  4. 客户端浏览器解析响应头,将会话凭证存入浏览器运行内存中的 Cookie 存储区。

后续状态溯源:

  1. 客户端对同源地址发起后续请求,浏览器自动检索内存中匹配的 Cookie。

  2. 客户端在 HTTP 请求头中附加凭证信息:Cookie: JSESSIONID=xxxxxx。

  3. 容器内部的解析链提取请求头中的标识字符串。

  4. 会话管理器以该标识为 Key,检索内存映射表,定位到已有的 Session 实例并将其封装到 HttpServletRequest 中供业务代码调用。

image-20260920145112104

URL 重写机制 ​

当客户端浏览器策略严格禁用 Cookie 时,基于 Cookie 的标准交互链路将中断,此时依靠 URL 重写机制完成退化容灾。

URL 重写机制:

URL 重写将 JSESSIONID 作为矩阵参数直接拼接到超链接或表单跳转的目标 URL 末尾,格式形如:[https://example.com/item;jsessionid=xxxxxx](https://example.com/item;jsessionid=xxxxxx)。当请求到达时,Servlet 容器会先检测 URL 是否带有 jsessionid 参数,若存在则同样可以定位到服务端的 Session。

重写编码实现:

java
// 当客户端禁用 Cookie 时将当前会话标识附加编码至目标 URL
String finalUrl = response.encodeURL("/user/profile");

// 登录成功后重新生成会话标识以阻断会话固定攻击
request.changeSessionId();

容器内幕 ​

以 Tomcat 为例,Session 的内部流转由特定的容器组件与异步守护任务共同维系。

会话管理器体系:

  • StandardManager:默认的非持久化内存管理器,负责会话对象的创建、检索与生命周期监听;在应用平滑重启时会将未超时的会话序列化暂存至本地磁盘文件 SESSIONS.ser。
  • PersistentManager:用于支撑大规模会话分发,支持配置换出策略,将会话持久化存入数据库或外部存储引擎中。

后台失效扫描:

容器内部启动了定期的后台守护线程,周期性触发 backgroundProcess() 任务:

  1. 遍历内存映射表中的所有活跃会话实例。

  2. 获取当前系统时间戳并与 Session 的最后访问时间(lastAccessedTime)求差值。

  3. 比对该差值是否超过配置的最大空闲时长(maxInactiveInterval)。

  4. 对超时的 Session 触发属性解绑、广播销毁监听器,并从映射表中彻底移除该键值。

安全加固 ​

会话凭证是用户在服务端的身份代理,核心防御机制聚焦于防窃取与防冒用。

凭证防护属性:

在签发 Set-Cookie 时,服务端需对标识 Cookie 施加约束:

  • HttpOnly:禁止前端脚本(如 JavaScript document.cookie)读取会话 Cookie,阻断 XSS 脚本窃取凭证。
  • Secure:强制仅允许在 HTTPS 加密信道下向服务端发送该 Cookie,避免明文嗅探。
  • SameSite:配置为 Lax 或 Strict,限制跨站请求携带凭证,阻断跨站请求伪造(CSRF)。

会话固定防御:

会话固定攻击:在用户登录之前,攻击者可能诱导用户使用已知 JSESSIONID 访问站点;若登录后仍沿用该标识,攻击者即可接管该会话。

防御方案:在完成身份鉴权的一刻,显式调用 request.changeSessionId(),保留原会话数据的同时生成全新不可预测的标识。

生命周期 ​

Session 的生命周期是指服务端从为其分配内存空间、初始化对象,到在多次交互中维持状态,直至最终将其从内存中移除并释放资源的完整过程,通常划分为创建、维持、销毁三个核心阶段。

阶段1:会话创建 ​

触发条件 ​

触发条件:Session 并非在客户端建立底层 TCP 连接或首次发送 HTTP 请求时自动生成,而是严格由服务端代码按需触发:

  1. 业务代码中显式调用 request.getSession() 或 request.getSession(true)。

  2. 访问开启了 Session 支持的传统 JSP 页面(JSP 页面指令中默认配置为 session="true")。

如果调用 request.getSession(false),在当前没有关联会话时仅返回 null,不会触发创建动作;对于 HTML、CSS、图片等静态资源的请求,容器同样不会主动创建 Session。

初始化过程 ​

初始化过程:当创建条件触发后,容器在后台执行以下步骤:

  1. 容器在堆内存中开辟独立存储区域,实例化 HttpSession 对象。

  2. 容器调用内置安全随机算法生成全局唯一的不可预测字符串 JSESSIONID。

  3. 容器记录会话的创建时间戳(creationTime)与初次访问时间戳(lastAccessedTime)。

  4. 容器在 HTTP 响应头中注入 Set-Cookie: JSESSIONID=...; Path=/; HttpOnly,将标识回传给客户端保存。

阶段2:会话维持 ​

活跃度刷新 ​

活跃度刷新:在 Session 存活期内,客户端发起的每一次请求只要在 Cookie 中携带了匹配的 JSESSIONID,Servlet 容器检索到该实例后,会自动将其内部的 lastAccessedTime 更新为当前系统时间戳。只要客户端持续交互且间隔未超标,会话就会一直维持有效。

空闲时限 ​

空闲时限:Session 并不根据“绝对存在时长”进行淘汰,而是依靠“最大非活跃间隔时间”(Max Inactive Interval)。该指标衡量的是连续两次请求之间的最大允许空闲时间,只要用户在此期间产生交互,超时倒计时便会重置。

配置方式:

开发者可以通过多种途径调整空闲超时阈值(优先级依次降低):

  1. 代码动态设置:调用 session.setMaxInactiveInterval(int seconds),单位为秒。

  2. 应用局部配置:在项目的 web.xml 中设定,单位为分钟。

  3. 服务器全局配置:在 Tomcat 的 conf/web.xml 中全局设定,默认值为 30 分钟。

    xml
    <session-config>
      <!-- 设置当前应用中 Session 的最大空闲存活时长为 30 分钟 -->
      <session-timeout>30</session-timeout>
    </session-config>

阶段3:会话销毁 ​

Session 销毁时,容器会释放该对象占用的堆内存空间,并解绑作用域内的所有数据。主要包含三种触发方式:

超时失效 ​

当客户端连续处于静默状态的时间超过了设定的最大空闲间隔时长,容器后台的周期性清理任务会将该 Session 判定为过期并执行销毁。这是生产中最普遍的自然淘汰机制。

手动注销 ​

在业务逻辑中显式调用 session.invalidate() 方法。容器会立即切断该 Session 的有效性,解绑属性并从内存映射表中抹除。常用于用户点击“退出登录”(Logout)的业务场景。

容器关闭 ​

Web 应用卸载或 Servlet 容器正常停止时,Session 会被终止。如果服务器配置了持久化管理器,未超时的 Session 会在停机前被写出到磁盘中;若未配置或服务器异常崩溃,则内存数据直接丢弃。

java
// 动态指定当前会话的最大空闲超时时长为 1800 秒
session.setMaxInactiveInterval(1800);

// 查询当前会话最近一次与客户端交互的毫秒时间戳
long lastAccess = session.getLastAccessedTime();

// 立即作废当前会话并清空其在服务端绑定的所有属性
session.invalidate();

销毁误区 ​

浏览器关闭与会话存活:

在 Web 开发中最常见的误区是“用户关闭浏览器,服务端的 Session 就随之销毁”。实际上,客户端关闭浏览器不会导致服务端的 Session 被立即销毁。

误区产生机制:

  1. 服务端发出的 JSESSIONID 默认以“会话级 Cookie”的形式驻留在客户端浏览器的运行内存中。

  2. 浏览器关闭后,其内存中缓存的 Cookie 随之抹除。

  3. 用户重新打开浏览器访问同一站点时,请求头不再携带之前的 JSESSIONID。

  4. 服务端因为接收不到凭据,将其视作新访客并分配了新的 Session。

  5. 原先位于服务端的 Session 依旧驻留在 JVM 内存中,直到空闲时间超过阈值后,才由容器后台线程回收。

状态监听 ​

Servlet 规范提供了事件驱动的监听接口,用于在生命周期的关键拐点注入监控或业务处理逻辑。

jakarta.servlet.http.HttpSessionListener 接口专门用于跟踪 Session 的创建与销毁事件。

监听代码实现:

java
// 捕获会话创建事件并执行在线人数递增
@Override
public void sessionCreated(HttpSessionEvent se) {
  String id = se.getSession().getId();
}
// 捕获会话失效事件并执行在线人数递减
@Override
public void sessionDestroyed(HttpSessionEvent se) {
  String id = se.getSession().getId();
}

钝化活化@ ​

为了平衡高并发下的服务器内存开销以及保障容器重启时的状态连续性,Servlet 容器引入了钝化与活化机制。

钝化 ​

当服务器内存不足、Session 长期处于空闲状态,或者服务器准备正常关闭时,容器将内存中的 Session 对象序列化后保存到底层硬盘文件或持久化存储中。

活化 ​

当被钝化存储的 Session 再次收到对应客户端的 HTTP 请求,或者服务器重新启动完毕时,容器将硬盘中的序列化文件反序列化,重新恢复至服务端内存。

存放于 Session 作用域中的 Java 对象必须实现 java.io.Serializable 接口,否则在触发钝化时容器会抛出 NotSerializableException 异常。

集群进阶@ ​

在单机单实例环境下,Session 完全保存在本地 JVM 内存中;而在分布式或多节点负载均衡集群中,用户的连续请求可能被分发到不同节点,从而引发会话丢失。

解决方案对比 ​

方案名称实现方式优缺点
粘性会话 (Sticky Session)Nginx 通过 ip_hash 或 Cookie 将同一用户永远路由到同一机器配置简单;无法容灾,节点宕机后该节点所有用户会话丢失
Session 广播复制Tomcat 集群节点间通过局域网组播同步 Session无需第三方存储;大量组播占用内网带宽,只适合极小规模集群
分布式集中存储将 Session 从应用内存抽离,统一写入 Redis 等公共缓存生产主流方案;无状态扩缩容简单,需额外维护高可用缓存中间件